Micron Document
ZgRNS Git Node

Commit 57bca8360552c2004f90dd14da39ea25c2437ac6


Parents : 7d6d94e
Author : varna9000 <39020101+varna9000@users.noreply.github.com>
Date : 2026-07-26T09:48:13+03:00

urns: port RNS 1.4.x path re-balancing, keepalive and channel window fixes

Three behavioural fixes from reference RNS 1.4.0/1.4.1. Nothing in that
release changed the wire format, so peers interoperate either way -- these
are reliability fixes, not compatibility ones.

Link path re-balancing (1.4.1). A link request and the proof answering it
do not always travel the same number of hops: a route can shorten between
the two, or on a mesh with several paths they simply differ. Both ends
checked the count and dropped a mismatch, so the link never came up.

At a relay, a transit proof whose hop count differs from the link table
now gets its signature verified, and on success the new count is adopted
in both the link table and the path table before the proof is forwarded.
The signature check is mandatory here because this rewrites routing state,
so _validate_transit_lr_proof is split: _can_verify_lr() (strict flag +
native Ed25519), _verify_lr_proof_sig() which never fails open, and the
fail-open relay policy on top. Where a node cannot verify -- pure-Python
Ed25519 -- the old drop stands; an unauthenticated hop rewrite would be
worse than a link that fails to establish.

At the initiator the same correction is applied to the path table once the
link goes active. No second verify is spent: validate_proof() has already
checked the peer's signature over our link id, so reaching ACTIVE is proof
enough. Upstream verifies twice only because it needs the value before its
own strict hop check.

Keepalive on outbound silence (1.4.0). What stales a link at the far end
is how long since WE transmitted, not how long since we heard. The
initiator gated keepalives on last_activity, which is inbound-only, so a
peer streaming to us kept it fresh, we never probed, and the peer tore the
link down mid-stream. Both link classes now track last_outbound and the
gate opens on either direction. Resource parts sent straight from
resource.py are deliberately not stamped: under-reporting costs a
redundant 1-byte probe, over-reporting would suppress a keepalive the peer
is waiting for.

Channel window (1.4.1). A sequence past the far edge of the receive window
was buffered in _rx_ring, where it stalled every later message behind a
gap that could never be filled and held its RAM for the life of the link.

Verified on a T-Deck (frozen build shadowed by a renamed copy of the
package): 18/18 checks with real native Ed25519, including the re-balance
path the host rig cannot reach -- _can_verify_lr() is False under CPython,
so host tests only ever exercise the fail-closed branch. Host suite green,
transport 51->57 and link 13->17 checks; the new tests were confirmed to
fail against the pre-change source.

Changes
Diff

diff --git a/README.md b/README.md
index 8358c4c..76223f7 100644
--- a/README.md
+++ b/README.md
@@ -630,11 +630,19 @@ Tested and confirmed working with:
- **RNode** (SX1276 / SX1278) — bidirectional LoRa, full split-packet support for the complete 500-byte MTU. Tested with Heltec Wireless Stick Lite V1 on 868 MHz.
- **RNS transport servers** — TCP client connectivity to remote transport hubs, automatic path learning from announces
-Protocol behaviour tracks **reference RNS 1.3.9**. Its link and resource
+Protocol behaviour tracks **reference RNS 1.4.1**. The 1.3.9 link and resource
safeguards are implemented here (see the *Resource and link safeguards* block
-under [Protocol details](#protocol-details)); the parts of that release that do
-not apply to an MCU port — `BackboneInterface` flap-blocking, interface
-discovery, and the `rnsh` utility — are out of scope.
+under [Protocol details](#protocol-details)), as are the 1.4.x fixes that matter
+on a mesh: dynamic link path re-balancing, keepalives that also trigger on
+outbound silence, and out-of-window rejection on `Channel` (see *Link path
+re-balancing* below). Nothing in 1.4.x changed the wire format, so older and
+newer peers interoperate either way.
+
+Out of scope for an MCU port: `BackboneInterface` flap-blocking, interface
+discovery/gravity, I2P, and the `rnsh` utility. Not yet ported: per-destination
+`max_request_size` / `max_response_size` limits (a hard 16 KB resource cap
+already applies) and the non-initiator keepalive-reply throttle, which upstream
+sizes from a measured link RTT this port does not yet track.
> **If you run an `rnsh` listener** (any platform), update it to RNS 1.3.9: that
> release patches a critical vulnerability where a command could be started on a
@@ -822,6 +830,38 @@ Covered by `firmware/tests/test_resource_safeguards.py`.
</details>
+<details>
+<summary><b>Link path re-balancing and keepalives (parity with RNS 1.4.x)</b></summary>
+
+A link request and the proof that answers it do not always travel the same
+number of hops — a route can shorten or lengthen between the two, and on a mesh
+with several possible paths they can simply differ. Both ends check the proof's
+hop count, so a mismatch used to mean the link never came up at all.
+
+- **Re-balancing at a relay** — when a transit link-request proof arrives with a
+ hop count other than the one recorded for that link, the relay verifies the
+ proof signature and then adopts the new count, in both the link table and the
+ path table, instead of dropping the proof. The signature check is mandatory
+ here: this rewrites routing state, so an unverifiable proof (no native
+ Ed25519, or `strict_lr_validation` off) is still dropped — a failed link beats
+ an unauthenticated hop rewrite.
+- **Re-balancing at the initiator** — the same correction is applied to the path
+ table once our own link goes active. No extra crypto is spent: the link only
+ reaches that state after the peer's signature over our link id has been
+ verified.
+- **Keepalive on outbound silence** — what stales a link at the far end is how
+ long since *we* transmitted, not how long since we heard. An initiator that
+ only receives (a peer streaming to it) used to fall silent and get torn down
+ mid-stream; it now probes when either direction has been quiet.
+- **Channel window** — a message sequence past the far edge of the receive
+ window is rejected rather than buffered forever behind a gap that can never be
+ filled.
+
+Covered by `firmware/tests/test_transport.py`, `test_link_request.py` and
+`test_channel.py`.
+
+</details>
+
<details>
<summary><b>SX1262 LoRa — RNode split-packet protocol</b></summary>

diff --git a/firmware/tests/test_channel.py b/firmware/tests/test_channel.py
index 07ff21f..07ab7e2 100644
--- a/firmware/tests/test_channel.py
+++ b/firmware/tests/test_channel.py
@@ -155,6 +155,37 @@ def test_receive_out_of_order():
print("ok test_receive_out_of_order")
+def test_receive_beyond_window_dropped():
+ # A sequence past the far edge of the receive window is rejected outright
+ # (RNS 1.4.1). Buffering it would stall every later message behind a gap
+ # that can never be filled, and hold its RAM for the life of the link.
+ ch = channel.Channel(MockOutlet(rtt=0.1))
+ ch.register_message_type(Ping)
+ got = []
+ ch.add_message_handler(lambda m: got.append(m.data) or False)
+ ch._receive(_raw(0, b"a"))
+ edge = ch._next_rx_sequence + channel.Channel.WINDOW_MAX # last acceptable
+ ch._receive(_raw(edge + 1, b"far")) # one past it
+ assert len(ch._rx_ring) == 0, ch._rx_ring # not buffered
+ ch._receive(_raw(1, b"b")) # stream continues
+ assert got == [b"a", b"b"], got
+ assert ch._next_rx_sequence == 2
+ print("ok test_receive_beyond_window_dropped")
+
+
+def test_receive_within_window_buffered():
+ # The edge case just inside the window is still accepted and buffered.
+ ch = channel.Channel(MockOutlet(rtt=0.1))
+ ch.register_message_type(Ping)
+ got = []
+ ch.add_message_handler(lambda m: got.append(m.data) or False)
+ edge = ch._next_rx_sequence + channel.Channel.WINDOW_MAX
+ ch._receive(_raw(edge, b"edge"))
+ assert len(ch._rx_ring) == 1
+ assert got == []
+ print("ok test_receive_within_window_buffered")
+
+
def test_receive_duplicate():
ch = channel.Channel(MockOutlet(rtt=0.1))
ch.register_message_type(Ping)

diff --git a/firmware/tests/test_link_request.py b/firmware/tests/test_link_request.py
index b749291..db5bbc3 100644
--- a/firmware/tests/test_link_request.py
+++ b/firmware/tests/test_link_request.py
@@ -50,9 +50,12 @@ def _mklink():
ol.remote_identified_callback = None
ol.remote_identity = None
ol.last_activity = time.time()
+ ol.last_outbound = time.time()
ol.request_time = time.time()
ol.activated_at = time.time()
ol.establishment_timeout = 60
+ ol.expected_hops = 1
+ ol.rebalanced = None
ol.rtt = 0
ol.mdu = 431
ol._channel = None
@@ -297,6 +300,71 @@ def test_outgoing_resource_timeout_frees_link():
assert not live.cancelled and live in ol.outgoing_resources
+# ---------------- initiator keepalive (RNS 1.4.0 stale-teardown fix) --------
+def _keepalive_raw(raw):
+ """(context, payload) of a link keepalive packet: ... | context(1) | data."""
+ return raw[-2], raw[-1]
+
+
+def test_keepalive_probes_on_outbound_silence():
+ """What stales the link at the far end is how long since WE transmitted,
+ not how long since we heard. A peer that streams to us keeps last_activity
+ fresh, so gating on inbound alone means we never probe, never transmit, and
+ the peer tears the link down mid-stream (fixed upstream in RNS 1.4.0)."""
+ mi, ol = _rig()
+ kival = ol._keepalive_interval()
+ now = time.time()
+ ol.last_activity = now # peer is talking to us constantly
+ ol.last_outbound = now - kival - 1 # ...but we have said nothing back
+ ol._last_keepalive = now - kival - 1
+ mi.sent.clear()
+
+ ol.check_keepalive()
+
+ assert len(mi.sent) == 1, mi.sent
+ ctx, payload = _keepalive_raw(mi.sent[0])
+ assert ctx == const.CTX_KEEPALIVE and payload == 0xFF
+ assert ol.last_outbound >= now # the probe itself counts as outbound
+
+ ol.check_keepalive() # rate-limited: no second probe
+ assert len(mi.sent) == 1
+
+
+def test_keepalive_probes_on_inbound_silence():
+ # The original trigger still works: nothing heard for a keepalive interval.
+ mi, ol = _rig()
+ kival = ol._keepalive_interval()
+ now = time.time()
+ ol.last_activity = now - kival - 1
+ ol.last_outbound = now
+ ol._last_keepalive = now - kival - 1
+ mi.sent.clear()
+
+ ol.check_keepalive()
+
+ assert len(mi.sent) == 1, mi.sent
+
+
+def test_keepalive_silent_when_both_directions_fresh():
+ mi, ol = _rig()
+ now = time.time()
+ ol.last_activity = now
+ ol.last_outbound = now
+ ol._last_keepalive = now - 10000 # not the limiting factor
+ mi.sent.clear()
+
+ ol.check_keepalive()
+
+ assert mi.sent == []
+
+
+def test_send_stamps_last_outbound():
+ mi, ol = _rig()
+ ol.last_outbound = time.time() - 100
+ ol.send(b"payload")
+ assert time.time() - ol.last_outbound < 1
+
+
def test_outgoing_resource_readvertises_until_answered():
"""A sender-side resource whose advertisement goes unanswered must
re-advertise (up to MAX_ADV_RETRIES) and then fail — one lost ADV must not

diff --git a/firmware/tests/test_transport.py b/firmware/tests/test_transport.py
index 05d55d0..66f16bd 100644
--- a/firmware/tests/test_transport.py
+++ b/firmware/tests/test_transport.py
@@ -488,6 +488,159 @@ def test_transit_lrproof_forwards_back_and_validates():
assert Transport.link_table[LINK_ID][const.IDX_LT_VALIDATED] is True
+# ------- link path re-balancing (RNS 1.4.1) --------------------------------
+_ORIG_CAN_VERIFY = Transport.__dict__["_can_verify_lr"]
+
+
+class _FakeIdent:
+ """Identity stand-in whose Ed25519 verdict the test controls."""
+
+ def __init__(self, ok=True):
+ self.ok = ok
+ self.checked = 0
+
+ def get_public_key(self):
+ return b"\x77" * 64
+
+ def validate(self, signature, data):
+ self.checked += 1
+ return self.ok
+
+
+def _verifiable(flag=True):
+ """Force _can_verify_lr(): the host rig has no native Ed25519, so it is
+ False here. The signature check itself still runs for real."""
+ Transport._can_verify_lr = staticmethod(lambda: flag)
+
+
+def _rebalance_rig(ok=True, rem_hops=3, path_hops=3):
+ reset_transport()
+ wifi = MockInterface("wifi")
+ lora = MockInterface("lora")
+ Transport.interfaces = [wifi, lora]
+ Transport.link_table[LINK_ID] = _link_entry(nh_if=lora, recv_if=wifi,
+ rem_hops=rem_hops, validated=False)
+ Transport.path_table[DEST] = _path(RELAY, path_hops, lora)
+ Identity.known[DEST] = _FakeIdent(ok=ok)
+ return wifi, lora
+
+
+def test_transit_lrproof_rebalances_hop_mismatch():
+ """The reverse path is not the length we recorded (topology moved, or the
+ request and the proof took different routes). Dropping the proof fails the
+ link outright, so adopt the count it came back with."""
+ wifi, lora = _rebalance_rig(ok=True, rem_hops=3, path_hops=3)
+ _verifiable(True)
+ try:
+ Transport.inbound(build_lrproof(LINK_ID, hops=0), lora) # arrives as hops=1
+ finally:
+ Transport._can_verify_lr = _ORIG_CAN_VERIFY
+
+ entry = Transport.link_table[LINK_ID]
+ assert len(wifi.sent) == 1 # forwarded, not dropped
+ assert entry[const.IDX_LT_REM_HOPS] == 1 # link table adopted it
+ assert Transport.path_table[DEST][const.IDX_PT_HOPS] == 1 # ...and the path table
+ assert entry[const.IDX_LT_VALIDATED] is True
+ assert Identity.known[DEST].checked == 1 # verified exactly once
+
+
+def test_transit_lrproof_rebalance_requires_valid_signature():
+ # A forged hop count must never rewrite routing tables.
+ wifi, lora = _rebalance_rig(ok=False, rem_hops=3, path_hops=3)
+ _verifiable(True)
+ try:
+ Transport.inbound(build_lrproof(LINK_ID, hops=0), lora)
+ finally:
+ Transport._can_verify_lr = _ORIG_CAN_VERIFY
+
+ entry = Transport.link_table[LINK_ID]
+ assert wifi.sent == [] and lora.sent == []
+ assert entry[const.IDX_LT_REM_HOPS] == 3
+ assert Transport.path_table[DEST][const.IDX_PT_HOPS] == 3
+ assert entry[const.IDX_LT_VALIDATED] is False
+
+
+def test_transit_lrproof_no_rebalance_when_unverifiable():
+ # No native Ed25519 (or strict validation off): the old drop stands, since
+ # an unauthenticated hop rewrite is worse than a link that fails to come up.
+ wifi, lora = _rebalance_rig(ok=True, rem_hops=3, path_hops=3)
+ assert Transport._can_verify_lr() is False # host rig default
+ Transport.inbound(build_lrproof(LINK_ID, hops=0), lora)
+
+ assert wifi.sent == []
+ assert Transport.link_table[LINK_ID][const.IDX_LT_REM_HOPS] == 3
+ assert Transport.path_table[DEST][const.IDX_PT_HOPS] == 3
+
+
+def test_transit_lrproof_rebalance_disabled_by_flag():
+ wifi, lora = _rebalance_rig(ok=True, rem_hops=3, path_hops=3)
+ _verifiable(True)
+ Transport.allow_link_path_rebalance = False
+ try:
+ Transport.inbound(build_lrproof(LINK_ID, hops=0), lora)
+ finally:
+ Transport._can_verify_lr = _ORIG_CAN_VERIFY
+ Transport.allow_link_path_rebalance = True
+
+ assert wifi.sent == []
+ assert Transport.link_table[LINK_ID][const.IDX_LT_REM_HOPS] == 3
+
+
+class _StubOutLink:
+ """Minimal initiator-side link: Transport only needs the proof handed over
+ and the resulting status to decide whether to re-balance."""
+ ACTIVE = 0x01
+ PENDING = 0x00
+
+ def __init__(self, link_id, dest_hash, expected_hops, validates=True):
+ self.link_id = link_id
+ self.status = _StubOutLink.PENDING
+ self.expected_hops = expected_hops
+ self.rebalanced = None
+ self.destination = _LocalDest(dest_hash)
+ self.proved = None
+ self._validates = validates
+
+ def validate_proof(self, packet):
+ self.proved = packet
+ if self._validates:
+ self.status = _StubOutLink.ACTIVE
+
+
+def test_lrproof_terminus_rebalances_path_table():
+ """We asked over a 3-hop path; the proof came back over 1. The path table
+ is what later announce comparisons and link timeouts read, so correct it."""
+ reset_transport()
+ lora = MockInterface("lora")
+ Transport.interfaces = [lora]
+ Transport.path_table[DEST] = _path(RELAY, 3, lora)
+ sl = _StubOutLink(LINK_ID, DEST, expected_hops=3)
+ Transport.pending_links.append(sl)
+
+ Transport.inbound(build_lrproof(LINK_ID, hops=0), lora) # arrives as hops=1
+
+ assert sl.proved is not None
+ assert sl.expected_hops == 1
+ assert sl.rebalanced is not None
+ assert Transport.path_table[DEST][const.IDX_PT_HOPS] == 1
+
+
+def test_lrproof_terminus_no_rebalance_without_validation():
+ # validate_proof() rejected the signature (link stays PENDING) -> the hop
+ # count in that proof is unauthenticated and must not touch the table.
+ reset_transport()
+ lora = MockInterface("lora")
+ Transport.interfaces = [lora]
+ Transport.path_table[DEST] = _path(RELAY, 3, lora)
+ sl = _StubOutLink(LINK_ID, DEST, expected_hops=3, validates=False)
+ Transport.pending_links.append(sl)
+
+ Transport.inbound(build_lrproof(LINK_ID, hops=0), lora)
+
+ assert sl.rebalanced is None
+ assert Transport.path_table[DEST][const.IDX_PT_HOPS] == 3
+
+
def test_transit_lrproof_wrong_interface_dropped():
reset_transport()
wifi = MockInterface("wifi")

diff --git a/firmware/urns/channel.py b/firmware/urns/channel.py
index d95dca0..cee0d1f 100644
--- a/firmware/urns/channel.py
+++ b/firmware/urns/channel.py
@@ -224,6 +224,14 @@ class Channel:
log("Channel: invalid seq " + str(envelope.sequence), LOG_EXTREME)
return
+ elif envelope.sequence > self._next_rx_sequence + Channel.WINDOW_MAX:
+ # Beyond the far edge of the receive window (RNS 1.4.1). Without
+ # this the envelope is buffered in _rx_ring forever: contiguous
+ # delivery never reaches its sequence, so every later message is
+ # held back too and the ring's RAM is never reclaimed.
+ log("Channel: invalid seq " + str(envelope.sequence), LOG_EXTREME)
+ return
+
is_new = self._emplace_envelope(envelope, self._rx_ring)
if not is_new:
log("Channel: duplicate message", LOG_EXTREME)

diff --git a/firmware/urns/link.py b/firmware/urns/link.py
index 5b8b204..791e024 100644
--- a/firmware/urns/link.py
+++ b/firmware/urns/link.py
@@ -42,6 +42,8 @@ class Link:
ACTIVE = 0x01
CLOSED = 0x02
+ last_outbound = 0 # see _had_outbound (class default for stubs)
+
KEEPALIVE_INTERVAL = 360 # seconds
STALE_GRACE = 720 # seconds
# Establishment timeout scales per hop, like reference RNS
@@ -92,6 +94,7 @@ class Link:
self.status = Link.PENDING
self.activated_at = None
self.last_activity = time.time()
+ self.last_outbound = time.time()
self.last_proof_time = time.time()
self.establishment_timeout = (Link.ESTABLISHMENT_BASE
+ Link.ESTABLISHMENT_PER_HOP * max(1, getattr(packet, "hops", 1)))
@@ -196,6 +199,7 @@ class Link:
attached_interface=self.attached_interface,
)
proof_packet.send()
+ self._had_outbound()
# Clean up (no longer needed after proof)
del self._ephemeral_pub_bytes, self._signalling_bytes
@@ -473,8 +477,19 @@ class Link:
const.PKT_PROOF, create_receipt=False,
)
proof.send()
+ self._had_outbound()
log("Link " + self.link_id.hex()[:8] + " proof sent for " + packet.packet_hash.hex()[:8], LOG_DEBUG)
+ def _had_outbound(self):
+ """Stamp the last time we put anything on the wire for this link. The
+ peer's watchdog stales a link it hears nothing on, so our own send
+ times — not just what we receive — decide whether a keepalive is due
+ (reference RNS 1.4.0 Link.had_outbound). Resource parts and proofs sent
+ straight from resource.py are not stamped; under-reporting only ever
+ costs a redundant 1-byte probe, while over-reporting would suppress a
+ keepalive the peer is waiting for."""
+ self.last_outbound = time.time()
+
def set_packet_callback(self, callback):
self.packet_callback = callback
@@ -502,6 +517,7 @@ class Link:
)
packet.MTU = self.mtu
packet.send()
+ self._had_outbound()
def check_keepalive(self):
"""Check link staleness and send keepalive if needed."""
@@ -587,6 +603,13 @@ class OutgoingLink:
_lrrtt_resends = 0
_lrrtt_last = 0
+ # Same for the keepalive / path re-balancing bookkeeping. last_outbound=0
+ # reads as "we have never transmitted", which errs toward sending a probe
+ # — the safe direction, since suppressing one lets the peer stale us.
+ last_outbound = 0
+ expected_hops = None
+ rebalanced = None
+
# Pending request states: SENT waits for a response packet or resource
# advertisement (request timeout applies); RECEIVING means the response
# is arriving as a resource — its own retry/cancel machinery governs
@@ -609,6 +632,7 @@ class OutgoingLink:
self._token = None
self.activated_at = None
self.last_activity = time.time()
+ self.last_outbound = time.time()
self.request_time = time.time()
self.rtt = 0 # measured at handshake (validate_proof)
self.type = const.DEST_LINK
@@ -630,9 +654,14 @@ class OutgoingLink:
self._lrrtt_last = 0
from .transport import Transport
+ # Hop count we believe the path has right now. The proof comes back over
+ # the path as it actually is, so a mismatch is what Transport uses to
+ # re-balance the path table (see Transport._rebalance_link_terminus).
+ self.expected_hops = Transport.hops_to(destination.hash)
+ self.rebalanced = None
self.establishment_timeout = (OutgoingLink.ESTABLISHMENT_BASE
+ OutgoingLink.ESTABLISHMENT_PER_HOP
- * max(1, Transport.hops_to(destination.hash)))
+ * max(1, self.expected_hops))
# Generate ephemeral X25519 keypair for ECDH
gc.collect()
@@ -796,6 +825,12 @@ class OutgoingLink:
const.PKT_DATA, context=context, create_receipt=False,
)
packet.send()
+ self._had_outbound()
+
+ def _had_outbound(self):
+ """Stamp the last time we transmitted on this link — see
+ Link._had_outbound and the keepalive gate in check_keepalive."""
+ self.last_outbound = time.time()
# --- Channel (rnsh etc.) ------------------------------------------------
@@ -828,6 +863,7 @@ class OutgoingLink:
from .packet import Packet, LinkDestination
Packet(LinkDestination(self.link_id), proof_data,
const.PKT_PROOF, create_receipt=False).send()
+ self._had_outbound()
def identify(self, identity):
"""Identify this initiator to the peer (reference Link.identify): send
@@ -862,6 +898,7 @@ class OutgoingLink:
Packet(LinkDestination(self.link_id), b"\xff",
const.PKT_DATA, context=const.CTX_KEEPALIVE,
create_receipt=False).send()
+ self._had_outbound()
log("OutLink " + self.link_id.hex()[:8] + " keepalive sent", LOG_DEBUG)
except Exception as e:
log("OutLink keepalive send error: " + str(e), LOG_DEBUG)
@@ -1141,9 +1178,15 @@ class OutgoingLink:
# Initiator keepalive: the peer stales the link if it hears nothing for
# a while, so send a 0xFF probe when idle (it replies 0xFE, refreshing
# last_activity). Only the initiator sends these (reference RNS).
+ #
+ # Probe on OUTBOUND silence as well as inbound (RNS 1.4.0 fix): what
+ # stales us at the far end is how long since *we* transmitted, not how
+ # long since we heard. A peer that streams to us keeps last_activity
+ # fresh, so gating on inbound alone means we never probe, we never
+ # transmit, and the peer tears the link down mid-stream.
now = time.time()
kival = self._keepalive_interval()
- if (now - self.last_activity >= kival
+ if ((now - self.last_activity >= kival or now - self.last_outbound >= kival)
and now - self._last_keepalive >= kival):
self._send_keepalive()
self._last_keepalive = now

diff --git a/firmware/urns/transport.py b/firmware/urns/transport.py
index 482cf5d..271eed9 100644
--- a/firmware/urns/transport.py
+++ b/firmware/urns/transport.py
@@ -68,6 +68,11 @@ class Transport:
# Native-gated: skipped when native Ed25519 is unavailable (avoids ~2s verify).
strict_lr_validation = True
+ # Adopt the hop count a link-request proof comes back with when it differs
+ # from what we recorded (RNS 1.4.1 "dynamic path re-balancing"). Requires a
+ # verified proof signature, so it is inert where _can_verify_lr() is False.
+ allow_link_path_rebalance = True
+
# Maintenance / persistence (Phase 5/6)
_last_cull = 0
_last_persist = 0
@@ -628,19 +633,32 @@ class Transport:
return True
@staticmethod
- def _validate_transit_lr_proof(packet, entry):
- """Verify a link-request proof's Ed25519 signature before relaying it
- (anti-DoS on an open mesh). Native-gated: returns True (forward) when
- native crypto is unavailable, to avoid a ~2s blocking verify per link."""
+ def _can_verify_lr():
+ """Whether an LRPROOF signature can actually be checked here: strict
+ validation enabled AND native Ed25519 present (a pure-Python verify is
+ ~2s and would stall the event loop for every relayed link)."""
if not Transport.strict_lr_validation:
- return True
+ return False
try:
from .crypto import ed25519
- native = ed25519.have_native()
+ return ed25519.have_native()
except Exception:
- native = False
- if not native:
+ return False
+
+ @staticmethod
+ def _validate_transit_lr_proof(packet, entry):
+ """Verify a link-request proof's Ed25519 signature before relaying it
+ (anti-DoS on an open mesh). Native-gated: returns True (forward) when
+ native crypto is unavailable, to avoid a ~2s blocking verify per link."""
+ if not Transport._can_verify_lr():
return True
+ return Transport._verify_lr_proof_sig(packet, entry)
+
+ @staticmethod
+ def _verify_lr_proof_sig(packet, entry):
+ """Check the proof signature against the link destination's identity.
+ Returns True only on a genuine pass — never fails open, so callers that
+ must not act on unauthenticated data (path re-balancing) can use it."""
try:
from .identity import Identity
from .link import ECPUBSIZE, LINK_MTU_SIZE, _parse_signalling, _signalling_bytes
@@ -672,13 +690,36 @@ class Transport:
entry = Transport.link_table.get(packet.destination_hash)
if entry is None:
return False
- if packet.hops != entry[const.IDX_LT_REM_HOPS]:
- log("Transit LRPROOF hop mismatch, ignoring", LOG_DEBUG)
- return False
if packet.receiving_interface is not entry[const.IDX_LT_NH_IF]:
log("Transit LRPROOF on wrong interface, ignoring", LOG_DEBUG)
return False
- if not Transport._validate_transit_lr_proof(packet, entry):
+
+ # Hop mismatch: the reverse path is not the length we recorded when the
+ # request passed through (topology moved, or the request and the proof
+ # took different routes). Dropping it fails the link outright, so adopt
+ # the proof's count instead — but ONLY against a verified signature,
+ # since this rewrites our routing tables. Where we cannot verify
+ # (_can_verify_lr false) the old drop stands: an unauthenticated hop
+ # rewrite is worse than a failed link. RNS 1.4.1 parity.
+ verified = False
+ if packet.hops != entry[const.IDX_LT_REM_HOPS]:
+ if (Transport.allow_link_path_rebalance
+ and not entry[const.IDX_LT_VALIDATED]
+ and Transport._can_verify_lr()
+ and Transport._verify_lr_proof_sig(packet, entry)):
+ verified = True
+ log("Re-balancing link " + packet.destination_hash.hex()[:8]
+ + " remaining hops " + str(entry[const.IDX_LT_REM_HOPS])
+ + " -> " + str(packet.hops), LOG_VERBOSE)
+ entry[const.IDX_LT_REM_HOPS] = packet.hops
+ pe = Transport.path_table.get(entry[const.IDX_LT_DEST])
+ if pe is not None:
+ pe[const.IDX_PT_HOPS] = packet.hops
+ else:
+ log("Transit LRPROOF hop mismatch, ignoring", LOG_DEBUG)
+ return False
+
+ if not verified and not Transport._validate_transit_lr_proof(packet, entry):
log("Transit LRPROOF failed validation, dropping", LOG_DEBUG)
return True # consumed (dropped)
Transport._cache_packet_hash(packet)
@@ -1489,6 +1530,7 @@ class Transport:
if link.link_id == packet.destination_hash:
Transport._cache_packet_hash(packet) # ours — remember now
link.validate_proof(packet)
+ Transport._rebalance_link_terminus(link, packet)
return True
elif packet.context == const.CTX_RESOURCE_PRF:
# Resource proof — route to the link
@@ -1503,6 +1545,32 @@ class Transport:
return True
return False
+ @staticmethod
+ def _rebalance_link_terminus(link, packet):
+ """We are the link initiator and the proof arrived over a path whose
+ length differs from what the path table claimed. Adopt the real count
+ (RNS 1.4.1). No separate signature check is needed here — unlike a
+ relay, we only reach this after validate_proof() verified the peer's
+ Ed25519 signature over our link_id, so a link that just went ACTIVE has
+ already authenticated this packet. Re-balance once per link."""
+ if not Transport.allow_link_path_rebalance:
+ return
+ if link.status != link.ACTIVE or getattr(link, "rebalanced", None):
+ return
+ expected = getattr(link, "expected_hops", None)
+ if expected is None or packet.hops == expected:
+ return
+ link.rebalanced = time.time()
+ link.expected_hops = packet.hops
+ dest = getattr(getattr(link, "destination", None), "hash", None)
+ entry = Transport.path_table.get(dest) if dest else None
+ if entry is not None:
+ # Only a routed destination has a hop count to correct; a direct
+ # peer has no path-table entry and nothing to re-balance.
+ entry[const.IDX_PT_HOPS] = packet.hops
+ log("Re-balanced path to " + dest.hex()[:8] + " at link terminus,"
+ + " hops " + str(expected) + " -> " + str(packet.hops), LOG_VERBOSE)
+
@staticmethod
def hops_to(destination_hash):
"""Return known hop count to destination, or 0 if unknown."""

Served by rngit 1.5.2 - Generated in 0.08s